// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// Copyright Joyent and Node contributors. All rights reserved. MIT license.

import { primordials } from "ext:core/mod.js";
const {
  ArrayPrototypeForEach,
  ArrayPrototypeIncludes,
  ArrayPrototypeMap,
  ArrayPrototypePushApply,
  ArrayPrototypeShift,
  ArrayPrototypeSlice,
  ArrayPrototypePush,
  ArrayPrototypeUnshiftApply,
  ObjectHasOwn,
  ObjectEntries,
  StringPrototypeCharAt,
  StringPrototypeIndexOf,
  StringPrototypeSlice,
  StringPrototypeStartsWith,
} = primordials;

import {
  validateArray,
  validateBoolean,
  validateBooleanArray,
  validateObject,
  validateString,
  validateStringArray,
  validateUnion,
} from "ext:deno_node/internal/validators.mjs";

import {
  findLongOptionForShort,
  isLoneLongOption,
  isLoneShortOption,
  isLongOptionAndValue,
  isOptionLikeValue,
  isOptionValue,
  isShortOptionAndValue,
  isShortOptionGroup,
  objectGetOwn,
  optionsGetOwn,
  useDefaultValueOption,
} from "ext:deno_node/internal/util/parse_args/utils.js";

import { codes } from "ext:deno_node/internal/error_codes.ts";
const {
  ERR_INVALID_ARG_VALUE,
  ERR_PARSE_ARGS_INVALID_OPTION_VALUE,
  ERR_PARSE_ARGS_UNKNOWN_OPTION,
  ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL,
} = codes;

import process from "node:process";

function getMainArgs() {
  // Work out where to slice process.argv for user supplied arguments.

  // Check node options for scenarios where user CLI args follow executable.
  const execArgv = process.execArgv;
  if (
    ArrayPrototypeIncludes(execArgv, "-e") ||
    ArrayPrototypeIncludes(execArgv, "--eval") ||
    ArrayPrototypeIncludes(execArgv, "-p") ||
    ArrayPrototypeIncludes(execArgv, "--print")
  ) {
    return ArrayPrototypeSlice(process.argv, 1);
  }

  // Normally first two arguments are executable and script, then CLI arguments
  return ArrayPrototypeSlice(process.argv, 2);
}

/**
 * In strict mode, throw for possible usage errors like --foo --bar
 * @param {object} token - from tokens as available from parseArgs
 */
function checkOptionLikeValue(token) {
  if (!token.inlineValue && isOptionLikeValue(token.value)) {
    // Only show short example if user used short option.
    const example = StringPrototypeStartsWith(token.rawName, "--")
      ? `'${token.rawName}=-XYZ'`
      : `'--${token.name}=-XYZ' or '${token.rawName}-XYZ'`;
    const errorMessage = `Option '${token.rawName}' argument is ambiguous.
Did you forget to specify the option argument for '${token.rawName}'?
To specify an option argument starting with a dash use ${example}.`;
    throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(errorMessage);
  }
}

/**
 * In strict mode, throw for usage errors.
 * @param {object} config - from config passed to parseArgs
 * @param {object} token - from tokens as available from parseArgs
 */
function checkOptionUsage(config, token) {
  if (!ObjectHasOwn(config.options, token.name)) {
    throw new ERR_PARSE_ARGS_UNKNOWN_OPTION(
      token.rawName,
      config.allowPositionals,
    );
  }

  const short = optionsGetOwn(config.options, token.name, "short");
  const shortAndLong = `${short ? `-${short}, ` : ""}--${token.name}`;
  const type = optionsGetOwn(config.options, token.name, "type");
  if (type === "string" && typeof token.value !== "string") {
    throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(
      `Option '${shortAndLong} <value>' argument missing`,
    );
  }
  // (Idiomatic test for undefined||null, expecting undefined.)
  if (type === "boolean" && token.value != null) {
    throw new ERR_PARSE_ARGS_INVALID_OPTION_VALUE(
      `Option '${shortAndLong}' does not take an argument`,
    );
  }
}

/**
 * Store the option value in `values`.
 * @param {string} longOption - long option name e.g. 'foo'
 * @param {string|undefined} optionValue - value from user args
 * @param {object} options - option configs, from parseArgs({ options })
 * @param {object} values - option values returned in `values` by parseArgs
 */
function storeOption(longOption, optionValue, options, values) {
  if (longOption === "__proto__") {
    return; // No. Just no.
  }

  // We store based on the option value rather than option type,
  // preserving the users intent for author to deal with.
  const newValue = optionValue ?? true;
  if (optionsGetOwn(options, longOption, "multiple")) {
    // Always store value in array, including for boolean.
    // values[longOption] starts out not present,
    // first value is added as new array [newValue],
    // subsequent values are pushed to existing array.
    // (note: values has null prototype, so simpler usage)
    if (values[longOption]) {
      ArrayPrototypePush(values[longOption], newValue);
    } else {
      values[longOption] = [newValue];
    }
  } else {
    values[longOption] = newValue;
  }
}

/**
 * Store the default option value in `values`.
 * @param {string} longOption - long option name e.g. 'foo'
 * @param {string
 *         | boolean
 *         | string[]
 *         | boolean[]} optionValue - default value from option config
 * @param {object} values - option values returned in `values` by parseArgs
 */
function storeDefaultOption(longOption, optionValue, values) {
  if (longOption === "__proto__") {
    return; // No. Just no.
  }

  values[longOption] = optionValue;
}

/**
 * Process args and turn into identified tokens:
 * - option (along with value, if any)
 * - positional
 * - option-terminator
 * @param {string[]} args - from parseArgs({ args }) or mainArgs
 * @param {object} options - option configs, from parseArgs({ options })
 */
function argsToTokens(args, options) {
  const tokens = [];
  let index = -1;
  let groupCount = 0;

  const remainingArgs = ArrayPrototypeSlice(args);
  while (remainingArgs.length > 0) {
    const arg = ArrayPrototypeShift(remainingArgs);
    const nextArg = remainingArgs[0];
    if (groupCount > 0) {
      groupCount--;
    } else {
      index++;
    }

    // Check if `arg` is an options terminator.
    // Guideline 10 in https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html
    if (arg === "--") {
      // Everything after a bare '--' is considered a positional argument.
      ArrayPrototypePush(tokens, { kind: "option-terminator", index });
      ArrayPrototypePushApply(
        tokens,
        ArrayPrototypeMap(remainingArgs, (arg) => {
          return { kind: "positional", index: ++index, value: arg };
        }),
      );
      break; // Finished processing args, leave while loop.
    }

    if (isLoneShortOption(arg)) {
      // e.g. '-f'
      const shortOption = StringPrototypeCharAt(arg, 1);
      const longOption = findLongOptionForShort(shortOption, options);
      let value;
      let inlineValue;
      if (
        optionsGetOwn(options, longOption, "type") === "string" &&
        isOptionValue(nextArg)
      ) {
        // e.g. '-f', 'bar'
        value = ArrayPrototypeShift(remainingArgs);
        inlineValue = false;
      }
      ArrayPrototypePush(
        tokens,
        {
          kind: "option",
          name: longOption,
          rawName: arg,
          index,
          value,
          inlineValue,
        },
      );
      if (value != null) ++index;
      continue;
    }

    if (isShortOptionGroup(arg, options)) {
      // Expand -fXzy to -f -X -z -y
      const expanded = [];
      for (let index = 1; index < arg.length; index++) {
        const shortOption = StringPrototypeCharAt(arg, index);
        const longOption = findLongOptionForShort(shortOption, options);
        if (
          optionsGetOwn(options, longOption, "type") !== "string" ||
          index === arg.length - 1
        ) {
          // Boolean option, or last short in group. Well formed.
          ArrayPrototypePush(expanded, `-${shortOption}`);
        } else {
          // String option in middle. Yuck.
          // Expand -abfFILE to -a -b -fFILE
          ArrayPrototypePush(expanded, `-${StringPrototypeSlice(arg, index)}`);
          break; // finished short group
        }
      }
      ArrayPrototypeUnshiftApply(remainingArgs, expanded);
      groupCount = expanded.length;
      continue;
    }

    if (isShortOptionAndValue(arg, options)) {
      // e.g. -fFILE
      const shortOption = StringPrototypeCharAt(arg, 1);
      const longOption = findLongOptionForShort(shortOption, options);
      const value = StringPrototypeSlice(arg, 2);
      ArrayPrototypePush(
        tokens,
        {
          kind: "option",
          name: longOption,
          rawName: `-${shortOption}`,
          index,
          value,
          inlineValue: true,
        },
      );
      continue;
    }

    if (isLoneLongOption(arg)) {
      // e.g. '--foo'
      const longOption = StringPrototypeSlice(arg, 2);
      let value;
      let inlineValue;
      if (
        optionsGetOwn(options, longOption, "type") === "string" &&
        isOptionValue(nextArg)
      ) {
        // e.g. '--foo', 'bar'
        value = ArrayPrototypeShift(remainingArgs);
        inlineValue = false;
      }
      ArrayPrototypePush(
        tokens,
        {
          kind: "option",
          name: longOption,
          rawName: arg,
          index,
          value,
          inlineValue,
        },
      );
      if (value != null) ++index;
      continue;
    }

    if (isLongOptionAndValue(arg)) {
      // e.g. --foo=bar
      const equalIndex = StringPrototypeIndexOf(arg, "=");
      const longOption = StringPrototypeSlice(arg, 2, equalIndex);
      const value = StringPrototypeSlice(arg, equalIndex + 1);
      ArrayPrototypePush(
        tokens,
        {
          kind: "option",
          name: longOption,
          rawName: `--${longOption}`,
          index,
          value,
          inlineValue: true,
        },
      );
      continue;
    }

    ArrayPrototypePush(tokens, { kind: "positional", index, value: arg });
  }
  return tokens;
}

export const parseArgs = (config = { __proto__: null }) => {
  const args = objectGetOwn(config, "args") ?? getMainArgs();
  const strict = objectGetOwn(config, "strict") ?? true;
  const allowPositionals = objectGetOwn(config, "allowPositionals") ?? !strict;
  const returnTokens = objectGetOwn(config, "tokens") ?? false;
  const options = objectGetOwn(config, "options") ?? { __proto__: null };
  // Bundle these up for passing to strict-mode checks.
  const parseConfig = { args, strict, options, allowPositionals };

  // Validate input configuration.
  validateArray(args, "args");
  validateBoolean(strict, "strict");
  validateBoolean(allowPositionals, "allowPositionals");
  validateBoolean(returnTokens, "tokens");
  validateObject(options, "options");
  ArrayPrototypeForEach(
    ObjectEntries(options),
    ({ 0: longOption, 1: optionConfig }) => {
      validateObject(optionConfig, `options.${longOption}`);

      // type is required
      const optionType = objectGetOwn(optionConfig, "type");
      validateUnion(optionType, `options.${longOption}.type`, [
        "string",
        "boolean",
      ]);

      if (ObjectHasOwn(optionConfig, "short")) {
        const shortOption = optionConfig.short;
        validateString(shortOption, `options.${longOption}.short`);
        if (shortOption.length !== 1) {
          throw new ERR_INVALID_ARG_VALUE(
            `options.${longOption}.short`,
            shortOption,
            "must be a single character",
          );
        }
      }

      const multipleOption = objectGetOwn(optionConfig, "multiple");
      if (ObjectHasOwn(optionConfig, "multiple")) {
        validateBoolean(multipleOption, `options.${longOption}.multiple`);
      }

      const defaultValue = objectGetOwn(optionConfig, "default");
      if (defaultValue !== undefined) {
        let validator;
        switch (optionType) {
          case "string":
            validator = multipleOption ? validateStringArray : validateString;
            break;

          case "boolean":
            validator = multipleOption ? validateBooleanArray : validateBoolean;
            break;
        }
        validator(defaultValue, `options.${longOption}.default`);
      }
    },
  );

  // Phase 1: identify tokens
  const tokens = argsToTokens(args, options);

  // Phase 2: process tokens into parsed option values and positionals
  const result = {
    values: { __proto__: null },
    positionals: [],
  };
  if (returnTokens) {
    result.tokens = tokens;
  }
  ArrayPrototypeForEach(tokens, (token) => {
    if (token.kind === "option") {
      if (strict) {
        checkOptionUsage(parseConfig, token);
        checkOptionLikeValue(token);
      }
      storeOption(token.name, token.value, options, result.values);
    } else if (token.kind === "positional") {
      if (!allowPositionals) {
        throw new ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL(token.value);
      }
      ArrayPrototypePush(result.positionals, token.value);
    }
  });

  // Phase 3: fill in default values for missing args
  ArrayPrototypeForEach(
    ObjectEntries(options),
    ({ 0: longOption, 1: optionConfig }) => {
      const mustSetDefault = useDefaultValueOption(
        longOption,
        optionConfig,
        result.values,
      );
      if (mustSetDefault) {
        storeDefaultOption(
          longOption,
          objectGetOwn(optionConfig, "default"),
          result.values,
        );
      }
    },
  );

  return result;
};
